page.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import './style.scss';
  2. import '@/app/styles/stock-common.scss';
  3. import { cache } from 'react';
  4. import { Metadata } from 'next';
  5. import Link from 'next/link';
  6. import { notFound } from 'next/navigation';
  7. import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
  8. import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
  9. import Pager from '../_components/Pager';
  10. const TABS = [
  11. { value: 'price', label: '시세' },
  12. { value: 'news', label: '뉴스' },
  13. { value: 'disclosure', label: '공시' },
  14. { value: 'discussion', label: '토론' }
  15. ];
  16. const HISTORY_PER_PAGE = 30;
  17. type Props = {
  18. params: Promise<{
  19. code: string;
  20. }>;
  21. searchParams: Promise<{
  22. tab?: string;
  23. page?: string;
  24. }>;
  25. };
  26. // generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
  27. const getDetail = cache(async (code: string) => {
  28. return await fetchStockDetail(code);
  29. });
  30. export async function generateMetadata({ params }: Props): Promise<Metadata>
  31. {
  32. const { code } = await params;
  33. const res = await getDetail(code);
  34. if (!res.success || !res.data) {
  35. return {
  36. title: '종목 상세 — 개미투자'
  37. };
  38. }
  39. return {
  40. title: `${res.data.name}(${res.data.code}) 주가·시세 — 개미투자`,
  41. description: `${res.data.name}(${res.data.code}) 전일 종가·등락률·거래량·일별 시세 — 전일 종가 기준(T+1)`
  42. };
  43. }
  44. export default async function StockDetailPage({ params, searchParams }: Props)
  45. {
  46. const { code } = await params;
  47. const query = await searchParams;
  48. const tab = TABS.some(c => c.value === query.tab) ? (query.tab as string) : 'price';
  49. const page = Math.max(Number(query.page) || 1, 1);
  50. const res = await getDetail(code);
  51. // 없는 코드(형식 불일치 포함)는 404
  52. if (!res.success && res.status === 404) {
  53. return notFound();
  54. }
  55. // 백엔드 미기동 등 그 외 실패 — 에러 UI 로 우아하게 처리
  56. if (!res.success || !res.data) {
  57. return (
  58. <div id='stock-detail'>
  59. <div className='stock-detail__error' role='alert'>
  60. <strong>종목 정보를 불러오지 못했습니다.</strong>
  61. 잠시 후 다시 시도해 주세요.
  62. </div>
  63. </div>
  64. );
  65. }
  66. const detail = res.data;
  67. const latest = detail.recentPrices.length > 0 ? detail.recentPrices[0] : null;
  68. const direction = changeDirection(detail.changeRate);
  69. const statusLabel = tradingStatusLabel(detail.tradingStatus);
  70. // 시세 탭만 히스토리 조회 (request-time)
  71. const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
  72. return (
  73. <div id='stock-detail'>
  74. {/* 상단 요약 — T+1 데이터임을 명시 */}
  75. <header className='stock-detail__summary'>
  76. <div className='stock-detail__title'>
  77. <h1>{detail.name}</h1>
  78. <span className='stock-detail__code'>{detail.code}</span>
  79. <span className={`stock-badge stock-badge--${detail.market.toLowerCase()}`}>{detail.market}</span>
  80. {statusLabel && (
  81. <span className={`stock-badge stock-badge--${detail.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
  82. )}
  83. {detail.sectorName && <span className='stock-detail__sector'>{detail.sectorName}</span>}
  84. </div>
  85. <div className='stock-detail__price'>
  86. <strong className={`stock-detail__close stock-change stock-change--${direction}`}>
  87. {formatNumber(detail.closePrice)}
  88. </strong>
  89. <span className={`stock-detail__diff stock-change stock-change--${direction}`}>
  90. {changeSymbol(direction)} {latest ? formatNumber(Math.abs(latest.changeAmount)) : '—'} ({formatChangeRate(detail.changeRate)})
  91. </span>
  92. </div>
  93. <dl className='stock-detail__meta'>
  94. <div>
  95. <dt>거래량</dt>
  96. <dd>{formatNumber(latest?.volume)}</dd>
  97. </div>
  98. <div>
  99. <dt>거래대금</dt>
  100. <dd>{formatKrwCompact(latest?.tradingValue)}</dd>
  101. </div>
  102. <div>
  103. <dt>시가총액</dt>
  104. <dd>{formatKrwCompact(detail.marketCap)}</dd>
  105. </div>
  106. </dl>
  107. <p className='stock-detail__basis'>
  108. 전일 기준 · {detail.lastTradingDate ?? '데이터 준비 중'} 종가 (T+1 반영)
  109. </p>
  110. </header>
  111. {/* 탭 */}
  112. <nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
  113. {TABS.map(item => (
  114. <Link
  115. key={item.value}
  116. href={item.value === 'price' ? `/stock/${detail.code}` : `/stock/${detail.code}?tab=${item.value}`}
  117. {...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
  118. >
  119. {item.label}
  120. </Link>
  121. ))}
  122. </nav>
  123. <section className='stock-detail__panel'>
  124. {tab !== 'price' ? (
  125. <p className='stock-detail__placeholder'>준비 중입니다.</p>
  126. ) : !history || !history.success || !history.data ? (
  127. <div className='stock-detail__error' role='alert'>
  128. <strong>일별 시세를 불러오지 못했습니다.</strong>
  129. 잠시 후 다시 시도해 주세요.
  130. </div>
  131. ) : history.data.list.length === 0 ? (
  132. <p className='stock-detail__empty'>수집된 일별 시세가 없습니다.</p>
  133. ) : (
  134. <>
  135. <table className='stock-detail__table'>
  136. <caption>{detail.name} 일별 시세 — 날짜, 종가, 등락, 시가, 고가, 저가, 거래량, 거래대금</caption>
  137. <thead>
  138. <tr>
  139. <th scope='col'>날짜</th>
  140. <th scope='col'>종가</th>
  141. <th scope='col'>등락률</th>
  142. <th scope='col' className='stock-detail__col--sub'>시가</th>
  143. <th scope='col' className='stock-detail__col--sub'>고가</th>
  144. <th scope='col' className='stock-detail__col--sub'>저가</th>
  145. <th scope='col'>거래량</th>
  146. <th scope='col' className='stock-detail__col--sub'>거래대금</th>
  147. </tr>
  148. </thead>
  149. <tbody>
  150. {history.data.list.map(row => {
  151. const rowDirection = changeDirection(row.changeRate);
  152. return (
  153. <tr key={row.tradingDate}>
  154. <td>{row.tradingDate}</td>
  155. <td>{formatNumber(row.close)}</td>
  156. <td className={`stock-change stock-change--${rowDirection}`}>
  157. {changeSymbol(rowDirection)} {formatChangeRate(row.changeRate)}
  158. </td>
  159. <td className='stock-detail__col--sub'>{formatNumber(row.open)}</td>
  160. <td className='stock-detail__col--sub'>{formatNumber(row.high)}</td>
  161. <td className='stock-detail__col--sub'>{formatNumber(row.low)}</td>
  162. <td>{formatNumber(row.volume)}</td>
  163. <td className='stock-detail__col--sub'>{formatKrwCompact(row.tradingValue)}</td>
  164. </tr>
  165. );
  166. })}
  167. </tbody>
  168. </table>
  169. <Pager
  170. total={history.data.total}
  171. page={page}
  172. perPage={HISTORY_PER_PAGE}
  173. basePath={`/stock/${detail.code}`}
  174. query={{}}
  175. />
  176. </>
  177. )}
  178. </section>
  179. </div>
  180. );
  181. }